<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ERC20 交互工具 (viem)</title>
  <style>
    :root {
      --bg:#0b0e14; --card:#11151f; --muted:#9aa4b2; --text:#e6eaf2; --accent:#4f8cff; --ok:#20c997; --warn:#ffc107; --err:#ff6b6b;
    }
    * { box-sizing: border-box; }
    body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; background: var(--bg); color: var(--text); }
    a{ color: var(--accent); }
    .wrap { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
    .grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:16px; }
    .card { background: var(--card); border:1px solid #1f2430; border-radius:16px; padding:16px; box-shadow: 0 6px 24px rgba(0,0,0,.25); }
    .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
    .row > * { flex: 1 1 auto; }
    .btn { background: var(--accent); color:#fff; border:none; padding:10px 14px; border-radius:12px; cursor:pointer; font-weight:600; }
    .btn:disabled { opacity:.6; cursor:not-allowed; }
    .btn.secondary { background:#2a2f3b; color:var(--text); }
    .btn.warn { background: var(--warn); color:#222; }
    .btn.ok { background: var(--ok); }
    .input, select { width:100%; padding:10px 12px; border-radius:12px; border:1px solid #293140; background:#0f1320; color:var(--text); outline: none; }
    .label { font-size:12px; color: var(--muted); margin-bottom:6px; }
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
    .kvs { display:grid; grid-template-columns: 200px 1fr; gap:10px; }
    .pill { font-size: 12px; border:1px solid #2a3040; border-radius:999px; padding:4px 10px; color: var(--muted); }
    .footer { text-align:center; color: var(--muted); margin-top: 18px; }
    .title { font-size:20px; font-weight:800; margin:0 0 8px; }
    .subtitle { color: var(--muted); margin-bottom: 12px; }
    .hr { height:1px; background:#1e2433; margin: 10px 0 16px; border:0; }
    .badge-ok{ color: var(--ok); }
    .badge-err{ color: var(--err); }
    code { background:#0f1320; border:1px solid #21283a; padding:2px 6px; border-radius:6px; }
    @media (max-width: 900px) {
      .kvs { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <div id="app" class="wrap">
    <div class="grid">
      <!-- 连接钱包和合约加载 -->
      <div class="card" style="grid-column: span 12;">
        <div class="row" style="justify-content: space-between; align-items: center; gap:8px">
          <div>
            <div class="title">ERC20 交互工具 (viem)</div>
          </div>
          <div class="row" style="gap:8px; flex:0 0 auto; align-items: center">
            <span class="pill">网络：<span class="mono" id="network">未知</span></span>
            <input class="input mono" id="tokenAddress" placeholder="合约地址" style="width: 400px;" value="0x5fbdb2315678afecb367f032d93f642f64180aa3" />
            <button class="btn" id="loadToken" disabled>加载合约</button>
            <button class="btn" id="connect">连接钱包</button>
            <span class="pill" id="tokenStatus"></span>
          </div>
        </div>
        <div class="subtitle" id="error"></div>
      </div>

      <!-- 左侧面板：转账和授权功能 -->
      <div class="card" style="grid-column: span 6;" id="tokenPanel" style="display:none;">
        <div class="title">转账功能</div>
        <div class="kvs mono">
          <div>我的余额</div><div id="balance">-</div>
        </div>
        <hr class="hr">

        <!-- 转账 -->
        <div style="margin-bottom: 16px;">
          <div class="label">转账 (transfer)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" id="transferTo" placeholder="收款地址" value="0xFDe2E81e2A23286944d7B2da17dcde22a9c78b2d" />
            </div>
            <div style="flex:0.7">
              <input class="input mono" id="transferAmount" placeholder="数量" value="1" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn ok" id="doTransfer">发送</button>
            </div>
          </div>
          <div class="subtitle" id="transferHash"></div>
        </div>

        <!-- 授权 -->
        <div style="margin-bottom: 16px;">
          <div class="label">授权 (approve)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" id="approveSpender" placeholder="被授权者地址" />
            </div>
            <div style="flex:0.7">
              <input class="input mono" id="approveAmount" placeholder="授权数量" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn warn" id="doApprove">授权</button>
            </div>
          </div>
          <div class="subtitle" id="approveHash"></div>
        </div>

        <!-- 授权转账功能 -->
        <div>
          <div class="label">授权转账 (transferFrom)</div>
          <div class="subtitle" style="color: var(--muted); margin-bottom: 8px;">
            需要先授权，然后使用授权转账功能
          </div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" id="transferFromFrom" placeholder="发送者地址" />
            </div>
            <div style="flex:1">
              <input class="input mono" id="transferFromTo" placeholder="收款地址" />
            </div>
            <div style="flex:0.7">
              <input class="input mono" id="transferFromAmount" placeholder="数量" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" id="doTransferFrom" disabled>授权转账</button>
            </div>
          </div>
          <div class="subtitle" id="transferFromHash"></div>
        </div>
      </div>

      <!-- 右侧面板：代币信息和查询功能 -->
      <div class="card" style="grid-column: span 6;" id="infoPanel" style="display:none;">
        <div class="title">代币信息</div>
        <div class="kvs mono">
          <div>名称</div><div id="tokenName">-</div>
          <div>符号</div><div id="tokenSymbol">-</div>
          <div>精度</div><div id="tokenDecimals">-</div>
          <div>总供应量</div><div id="tokenTotalSupply">-</div>
        </div>
        <hr class="hr">

        <!-- 查询余额 -->
        <div style="margin-bottom: 16px;">
          <div class="label">查询余额</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" id="queryAddress" placeholder="地址 (0x...)"/>
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" id="queryBalance">查询</button>
            </div>
          </div>
          <div style="margin-top:6px" class="mono" id="queryResult"></div>
        </div>

        <!-- 查询授权额度 -->
        <div>
          <div class="label">查询授权额度</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" id="allowanceOwner" placeholder="owner 地址" />
            </div>
            <div style="flex:1">
              <input class="input mono" id="allowanceSpender" placeholder="spender 地址" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" id="queryAllowance">查询</button>
            </div>
          </div>
          <div style="margin-top:6px" class="mono" id="allowanceResult"></div>
        </div>
      </div>

      <!-- 日志&提示 -->
      <div class="card" style="grid-column: span 12;">
        <div class="title">日志</div>
        <div class="mono" style="white-space: pre-wrap; line-height:1.5; max-height: 260px; overflow:auto; background:#0f1320; border:1px solid #1f2434; border-radius:12px; padding:10px;" id="logs">
等待连接...
        </div>
      </div>

      <div class="footer card" style="grid-column: span 12;">
        小贴士：
        <ul>
          <li>确保浏览器已安装 MetaMask 并在目标网络上；</li>
          <li>首次使用请点击"连接钱包"；</li>
          <li>金额输入按代币的人类可读单位；内部会自动用 decimals 转换。</li>
        </ul>
      </div>
    </div>
  </div>

<script type="module">
import { createWalletClient, createPublicClient, custom, http, parseUnits, formatUnits } from "https://esm.sh/viem";
import { sepolia } from "https://esm.sh/viem/chains";

// ERC20 ABI (viem 格式)
const ERC20_ABI = [
  {
    type: 'function',
    name: 'name',
    inputs: [],
    outputs: [{ type: 'string' }],
    stateMutability: 'view'
  },
  {
    type: 'function',
    name: 'symbol',
    inputs: [],
    outputs: [{ type: 'string' }],
    stateMutability: 'view'
  },
  {
    type: 'function',
    name: 'decimals',
    inputs: [],
    outputs: [{ type: 'uint8' }],
    stateMutability: 'view'
  },
  {
    type: 'function',
    name: 'totalSupply',
    inputs: [],
    outputs: [{ type: 'uint256' }],
    stateMutability: 'view'
  },
  {
    type: 'function',
    name: 'balanceOf',
    inputs: [{ name: 'account', type: 'address' }],
    outputs: [{ type: 'uint256' }],
    stateMutability: 'view'
  },
  {
    type: 'function',
    name: 'transfer',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' }
    ],
    outputs: [{ type: 'bool' }],
    stateMutability: 'nonpayable'
  },
  {
    type: 'function',
    name: 'allowance',
    inputs: [
      { name: 'owner', type: 'address' },
      { name: 'spender', type: 'address' }
    ],
    outputs: [{ type: 'uint256' }],
    stateMutability: 'view'
  },
  {
    type: 'function',
    name: 'approve',
    inputs: [
      { name: 'spender', type: 'address' },
      { name: 'amount', type: 'uint256' }
    ],
    outputs: [{ type: 'bool' }],
    stateMutability: 'nonpayable'
  },
  {
    type: 'function',
    name: 'transferFrom',
    inputs: [
      { name: 'from', type: 'address' },
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' }
    ],
    outputs: [{ type: 'bool' }],
    stateMutability: 'nonpayable'
  }
];

// 全局变量
let walletClient = null;
let publicClient = null;
let account = null;
let chainId = null;
let tokenContract = null;
let tokenInfo = { name: '', symbol: '', decimals: 18 };

// DOM 元素
const elements = {
  connect: document.getElementById('connect'),
  loadToken: document.getElementById('loadToken'),
  tokenAddress: document.getElementById('tokenAddress'),
  network: document.getElementById('network'),
  tokenStatus: document.getElementById('tokenStatus'),
  error: document.getElementById('error'),
  tokenPanel: document.getElementById('tokenPanel'),
  infoPanel: document.getElementById('infoPanel'),
  balance: document.getElementById('balance'),
  transferTo: document.getElementById('transferTo'),
  transferAmount: document.getElementById('transferAmount'),
  doTransfer: document.getElementById('doTransfer'),
  transferHash: document.getElementById('transferHash'),
  approveSpender: document.getElementById('approveSpender'),
  approveAmount: document.getElementById('approveAmount'),
  doApprove: document.getElementById('doApprove'),
  approveHash: document.getElementById('approveHash'),
  transferFromFrom: document.getElementById('transferFromFrom'),
  transferFromTo: document.getElementById('transferFromTo'),
  transferFromAmount: document.getElementById('transferFromAmount'),
  doTransferFrom: document.getElementById('doTransferFrom'),
  transferFromHash: document.getElementById('transferFromHash'),
  tokenName: document.getElementById('tokenName'),
  tokenSymbol: document.getElementById('tokenSymbol'),
  tokenDecimals: document.getElementById('tokenDecimals'),
  tokenTotalSupply: document.getElementById('tokenTotalSupply'),
  queryAddress: document.getElementById('queryAddress'),
  queryBalance: document.getElementById('queryBalance'),
  queryResult: document.getElementById('queryResult'),
  allowanceOwner: document.getElementById('allowanceOwner'),
  allowanceSpender: document.getElementById('allowanceSpender'),
  queryAllowance: document.getElementById('queryAllowance'),
  allowanceResult: document.getElementById('allowanceResult'),
  logs: document.getElementById('logs')
};

// 工具函数
function shortAddress(address) {
  return address ? address.slice(0, 6) + '…' + address.slice(-4) : '';
}

function log(message) {
  const timestamp = new Date().toLocaleTimeString();
  elements.logs.textContent = `[${timestamp}] ${message}\n` + elements.logs.textContent;
}

function showError(message) {
  elements.error.innerHTML = `<span class="badge-err">⚠</span> ${message}`;
}

function clearError() {
  elements.error.textContent = '';
}

// 网络名称映射
const networkMap = {
  '0x1': 'Ethereum',
  '0x5': 'Goerli',
  '0xaa36a7': 'Sepolia',
  '0x89': 'Polygon',
  '0x38': 'BSC',
  '0xa4b1': 'Arbitrum',
  '0x2105': 'Base'
};

function getNetworkName(chainId) {
  return networkMap[chainId] || chainId;
}

// 连接钱包
elements.connect.onclick = async () => {
  try {
    if (!window.ethereum) {
      showError('未检测到 MetaMask');
      return;
    }

    log('正在连接钱包...');

    // 请求账户授权
    await window.ethereum.request({ method: 'eth_requestAccounts' });

    // 获取当前账户
    const accounts = await window.ethereum.request({ method: 'eth_accounts' });
    account = accounts[0];

    // 获取当前网络
    chainId = await window.ethereum.request({ method: 'eth_chainId' });

    // 创建客户端
    const currentChain = {
      id: Number(chainId),
      name: 'Current Chain',
      nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
      rpcUrls: { default: { http: [''] } }
    };

    walletClient = createWalletClient({
      account,
      chain: currentChain,
      transport: custom(window.ethereum)
    });

    publicClient = createPublicClient({
      chain: currentChain,
      transport: custom(window.ethereum)
    });

    // 更新界面
    elements.connect.textContent = shortAddress(account);
    elements.network.textContent = getNetworkName(chainId);
    elements.loadToken.disabled = false;

    log(`已连接 ${account} @ ${getNetworkName(chainId)}`);

    // 监听网络变化
    window.ethereum.on('chainChanged', (newChainId) => {
      chainId = newChainId;
      elements.network.textContent = getNetworkName(chainId);
      log(`网络已切换至 ${getNetworkName(chainId)}`);
      // 重新加载合约
      if (tokenContract) {
        loadTokenInfo();
      }
    });

    // 监听账户变化
    window.ethereum.on('accountsChanged', (accounts) => {
      account = accounts[0] || null;
      if (account) {
        elements.connect.textContent = shortAddress(account);
        log(`账户已切换至 ${account}`);
        if (tokenContract) {
          refreshBalance();
        }
      } else {
        elements.connect.textContent = '连接钱包';
        elements.loadToken.disabled = true;
        log('账户已断开');
      }
    });

  } catch (error) {
    showError('连接失败: ' + error.message);
    log('连接失败: ' + error.message);
  }
};

// 加载合约
elements.loadToken.onclick = async () => {
  try {
    clearError();
    const tokenAddress = elements.tokenAddress.value.trim();

    if (!tokenAddress) {
      showError('请输入合约地址');
      return;
    }

    if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) {
      showError('合约地址格式不正确');
      return;
    }

    log(`正在加载合约 ${tokenAddress}...`);

    tokenContract = {
      address: tokenAddress,
      abi: ERC20_ABI
    };

    await loadTokenInfo();

  } catch (error) {
    showError('加载合约失败: ' + error.message);
    log('加载合约失败: ' + error.message);
  }
};

// 加载代币信息
async function loadTokenInfo() {
  try {
    const [name, symbol, decimals, totalSupply] = await Promise.all([
      publicClient.readContract({
        ...tokenContract,
        functionName: 'name'
      }),
      publicClient.readContract({
        ...tokenContract,
        functionName: 'symbol'
      }),
      publicClient.readContract({
        ...tokenContract,
        functionName: 'decimals'
      }),
      publicClient.readContract({
        ...tokenContract,
        functionName: 'totalSupply'
      })
    ]);

    tokenInfo = { name, symbol, decimals: Number(decimals) };

    // 更新界面
    elements.tokenName.textContent = name;
    elements.tokenSymbol.textContent = symbol;
    elements.tokenDecimals.textContent = decimals;
    elements.tokenTotalSupply.textContent = formatUnits(totalSupply, decimals);
    elements.tokenStatus.textContent = `已加载: ${symbol}`;

    // 显示面板
    elements.tokenPanel.style.display = 'block';
    elements.infoPanel.style.display = 'block';

    log(`已加载 ${symbol} (${name}), decimals=${decimals}`);

    await refreshBalance();

  } catch (error) {
    showError('读取代币信息失败: ' + error.message);
    log('读取代币信息失败: ' + error.message);
  }
}

// 刷新余额
async function refreshBalance() {
  try {
    if (!tokenContract || !account) return;

    const balance = await publicClient.readContract({
      ...tokenContract,
      functionName: 'balanceOf',
      args: [account]
    });

    elements.balance.textContent = `${formatUnits(balance, tokenInfo.decimals)} ${tokenInfo.symbol}`;
  } catch (error) {
    elements.balance.textContent = '-';
    log('读取余额失败: ' + error.message);
  }
}

// 转账
elements.doTransfer.onclick = async () => {
  try {
    const to = elements.transferTo.value.trim();
    const amount = elements.transferAmount.value.trim();

    if (!to || !amount) {
      showError('请填写收款地址和金额');
      return;
    }

    if (!/^0x[a-fA-F0-9]{40}$/.test(to)) {
      showError('收款地址格式不正确');
      return;
    }

    const parsedAmount = parseUnits(amount, tokenInfo.decimals);

    log(`正在发送转账...`);

    const hash = await walletClient.writeContract({
      ...tokenContract,
      functionName: 'transfer',
      args: [to, parsedAmount],
      account
    });

    elements.transferHash.innerHTML = `交易：<a href="https://sepolia.etherscan.io/tx/${hash}" target="_blank">${shortAddress(hash)}</a>`;
    log(`转账已提交: ${hash}`);

    // 等待交易确认
    const receipt = await publicClient.waitForTransactionReceipt({ hash });
    log(`转账成功，区块：${receipt.blockNumber}`);

    await refreshBalance();

  } catch (error) {
    showError('转账失败: ' + error.message);
    log('转账失败: ' + error.message);
  }
};

// 授权
elements.doApprove.onclick = async () => {
  try {
    const spender = elements.approveSpender.value.trim();
    const amount = elements.approveAmount.value.trim();

    if (!spender || !amount) {
      showError('请填写被授权者地址和授权金额');
      return;
    }

    if (!/^0x[a-fA-F0-9]{40}$/.test(spender)) {
      showError('被授权者地址格式不正确');
      return;
    }

    const parsedAmount = parseUnits(amount, tokenInfo.decimals);

    log(`正在发送授权...`);

    const hash = await walletClient.writeContract({
      ...tokenContract,
      functionName: 'approve',
      args: [spender, parsedAmount],
      account
    });

    elements.approveHash.innerHTML = `交易：<a href="https://sepolia.etherscan.io/tx/${hash}" target="_blank">${shortAddress(hash)}</a>`;
    log(`授权已提交: ${hash}`);

    // 等待交易确认
    const receipt = await publicClient.waitForTransactionReceipt({ hash });
    log(`授权成功，区块：${receipt.blockNumber}`);

  } catch (error) {
    showError('授权失败: ' + error.message);
    log('授权失败: ' + error.message);
  }
};

// 查询余额
elements.queryBalance.onclick = async () => {
  try {
    const address = elements.queryAddress.value.trim();

    if (!address) {
      showError('请输入查询地址');
      return;
    }

    if (!/^0x[a-fA-F0-9]{40}$/.test(address)) {
      showError('地址格式不正确');
      return;
    }

    const balance = await publicClient.readContract({
      ...tokenContract,
      functionName: 'balanceOf',
      args: [address]
    });

    elements.queryResult.textContent = `${formatUnits(balance, tokenInfo.decimals)} ${tokenInfo.symbol}`;
    log(`查询余额: ${address} = ${formatUnits(balance, tokenInfo.decimals)} ${tokenInfo.symbol}`);

  } catch (error) {
    elements.queryResult.textContent = '-';
    showError('查询余额失败: ' + error.message);
    log('查询余额失败: ' + error.message);
  }
};

// 查询授权额度
elements.queryAllowance.onclick = async () => {
  try {
    const owner = elements.allowanceOwner.value.trim();
    const spender = elements.allowanceSpender.value.trim();

    if (!owner || !spender) {
      showError('请填写 owner 和 spender 地址');
      return;
    }

    if (!/^0x[a-fA-F0-9]{40}$/.test(owner) || !/^0x[a-fA-F0-9]{40}$/.test(spender)) {
      showError('地址格式不正确');
      return;
    }

    const allowance = await publicClient.readContract({
      ...tokenContract,
      functionName: 'allowance',
      args: [owner, spender]
    });

    elements.allowanceResult.textContent = `${formatUnits(allowance, tokenInfo.decimals)} ${tokenInfo.symbol}`;
    log(`查询授权额度: ${owner} -> ${spender} = ${formatUnits(allowance, tokenInfo.decimals)} ${tokenInfo.symbol}`);

  } catch (error) {
    elements.allowanceResult.textContent = '-';
    showError('查询授权额度失败: ' + error.message);
    log('查询授权额度失败: ' + error.message);
  }
};

// 初始化
log('使用 viem 库的 ERC20 交互工具已就绪');
</script>
</body>
</html>